feat(auth): add tab-based PKCE for Firefox mobile support (Phase 4)#271
feat(auth): add tab-based PKCE for Firefox mobile support (Phase 4)#271
Conversation
Implements OAuth 2.1 + PKCE authentication for Firefox (desktop and Android) using a tab-based flow that intercepts OAuth redirects via browser.tabs.onUpdated. Key changes: - Add TabBasedPKCEAuth.ts with tab-based PKCE flow for Firefox - Update OAuthService.ts to route Firefox to tab-based PKCE - Add isFirefoxAndroid() and isKiwiBrowser() platform detection - Update PKCE_AUTH_SPEC.md with Firefox implementation details The tab-based flow: 1. Opens OAuth authorization URL in a new browser tab 2. Monitors tab URL changes via browser.tabs.onUpdated 3. Detects redirect to extensions.allizom.org 4. Extracts authorization code and exchanges for tokens 5. Closes the auth tab automatically Server requirement: Register Firefox redirect URI to enable PKCE authentication. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
rosscado
left a comment
There was a problem hiding this comment.
Code Review: Tab-Based PKCE Authentication (Phase 4)
I've reviewed the implementation and found 3 critical bugs and 2 high-priority issues that should be addressed before merging.
🔴 Critical Bug #1: Memory Leak - removeListener Never Cleaned Up
File: src/auth/TabBasedPKCEAuth.ts lines 319-326, 178-195
The removeListener callback added to browser.tabs.onRemoved at line 326 is never removed when authentication succeeds.
// Line 326: Listener is added
const removeListener = (tabId: number) => { ... };
browser.tabs.onRemoved.addListener(removeListener);
// Line 178-195: cleanup() removes tabListener but NOT removeListener
const cleanup = async () => {
if (tabListener) {
browser.tabs.onUpdated.removeListener(tabListener); // ✅ Good
tabListener = undefined;
}
// ❌ MISSING: browser.tabs.onRemoved.removeListener(removeListener);
};Impact: Every successful auth leaves a dangling listener that continues to fire on all future tab closures.
Fix: Add removeListener to the cleanup() function or track it as a variable like tabListener.
🔴 Critical Bug #2: Race Condition - Listener Registered Before authTabId Set
File: src/auth/TabBasedPKCEAuth.ts lines 319-331
// Line 326: Listener added HERE - authTabId is still undefined
browser.tabs.onRemoved.addListener(removeListener);
// Lines 329-331: Tab created and authTabId assigned HERE - AFTER listener
const tab = await browser.tabs.create({ url: authUrl, active: true });
authTabId = tab.id;Issue: Between lines 326-331, if any tab is closed (not related to auth), removeListener executes with authTabId === undefined. Since the check tabId === authTabId will fail (comparing to undefined), it passes without cleanup. However, the listener remains forever active.
Fix: Move addListener after authTabId is assigned, or add a guard: if (authTabId === undefined) return;
🔴 Critical Bug #3: cleanup() Not Awaited in Synchronous Callbacks
File: src/auth/TabBasedPKCEAuth.ts lines 236-243, 247-254, 294-302, 322-324
The tabListener and removeListener are synchronous callbacks, but they call cleanup() (an async function) without awaiting:
// Lines 236-243: cleanup() not awaited
cleanup(); // ❌ Returns immediately
clearPKCEState(); // ❌ Races with cleanup()
resolve({...}); // ❌ Resolves before tab is actually closedImpact: The promise resolves before browser.tabs.remove() completes, and clearPKCEState() may race with storage operations inside cleanup().
Fix: Either make cleanup synchronous (remove the await inside) or restructure to handle async properly. Consider using a flag pattern like JwtManager.ts uses with isClearing.
🟡 High Priority: Concurrent Auth Call Vulnerability
File: src/auth/TabBasedPKCEAuth.ts lines 161-356
If authenticateWithTabBasedPKCE() is called multiple times concurrently (e.g., user rapid-clicks "Sign In"), each call registers its own listeners. Only the first call's cleanup will run, leaving others orphaned.
Fix: Add a guard flag at module scope to prevent concurrent auth attempts:
let authInProgress = false;
export async function authenticateWithTabBasedPKCE(): Promise<OAuthResult> {
if (authInProgress) {
return { success: false, error: 'auth_in_progress', errorDescription: 'Authentication already in progress' };
}
authInProgress = true;
try {
// ... existing code
} finally {
authInProgress = false;
}
}🟡 High Priority: removeListener Self-Removes But May Leave Artifacts
File: src/auth/TabBasedPKCEAuth.ts lines 319-325
const removeListener = (tabId: number) => {
if (tabId === authTabId) {
browser.tabs.onRemoved.removeListener(removeListener); // Self-removes
cleanup();
clearPKCEState();
// ❌ But never resolves the authPromise - leaves it hanging on timeout
}
};When user closes the auth tab, the removeListener fires and cleans up, but the authPromise is never resolved/rejected directly. It will eventually timeout after 5 minutes.
Fix: Add resolve() call in removeListener with appropriate error response.
✅ What Looks Good
- PKCE flow logic is correct
- State validation and CSRF protection are properly implemented
- Token exchange follows OAuth 2.1 spec
- Test coverage is comprehensive (14 tests)
- User agent detection functions are well-implemented
Summary
| # | Severity | Issue | Location |
|---|---|---|---|
| 1 | 🔴 Critical | Memory leak - removeListener never cleaned up | L319-326, L178-195 |
| 2 | 🔴 Critical | Race condition - listener before authTabId | L319-331 |
| 3 | 🔴 Critical | Async cleanup not awaited | L236-243, L247-254, etc |
| 4 | 🟡 High | Concurrent auth calls vulnerability | L161-356 |
| 5 | 🟡 High | User closes tab - promise hangs | L319-325 |
🤖 Generated with Claude Code
Fixes 5 issues identified in code review: 1. Memory leak - `removeListener` now tracked and cleaned up - Added `removeListener` variable to cleanup scope - cleanup() now removes both onUpdated and onRemoved listeners 2. Race condition - listeners now registered AFTER tab creation - Moved browser.tabs.create() before listener registration - Added guard checks for undefined authTabId in listeners 3. Async cleanup - made cleanup synchronous where needed - Changed cleanup() to synchronous function - Listener removal is immediate; tab removal is fire-and-forget - Added isCleanedUp guard flag to prevent multiple cleanups 4. Concurrent auth vulnerability - added authInProgress guard - Module-level flag prevents overlapping auth attempts - Returns 'auth_in_progress' error if already authenticating 5. Promise hanging when user closes tab - removeListener now resolves - removeListener callback now resolves promise with 'auth_cancelled' - Logs when user closes auth tab directly Added 5 new tests: - Concurrent auth prevention - User closing auth tab directly - removeListener cleanup verification - Auth allowed after previous completes - Listeners registered after tab creation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
✅ Code Review Issues ResolvedAll 5 issues from the code review have been addressed in commit cf1ad22: Fixes Applied
New Tests Added5 new tests verify the fixes:
All 699 tests pass ✅ 🤖 Generated with Claude Code |
rosscado
left a comment
There was a problem hiding this comment.
Code review
No issues found. Checked for bugs and CLAUDE.md compliance.
🤖 Generated with Claude Code
- If this code review was useful, please react with 👍. Otherwise, react with 👎.
Summary
Implements Phase 4 (Mobile Optimization) of the authentication flow PRD, adding OAuth 2.1 + PKCE support for Firefox (desktop and Android) using a tab-based flow.
Key Changes
TabBasedPKCEAuth.ts- Tab-based PKCE authentication flow for FirefoxOAuthService.ts- Routes Firefox to tab-based PKCE instead of cookie-based fallbackUserAgentModule.ts- AddisFirefoxAndroid()andisKiwiBrowser()detectionPKCE_AUTH_SPEC.md- Document Firefox implementation and redirect URIHow Tab-Based PKCE Works
browser.tabs.onUpdatedextensions.allizom.org(Firefox redirect URI)Platform Support
Server-Side Requirement
Action needed: Register the Firefox redirect URI on the server:
https://gecko@saypi.ai.extensions.allizom.org/Without this, Firefox PKCE authentication will fail with an unregistered redirect URI error.
Test plan
TabBasedPKCEAuth.ts(14 tests passing)isFirefoxAndroid()andisKiwiBrowser()(6 new tests)Generated with Claude Code